Skip to content

feat(auth): optional auth user cache for EloquentUserProvider#371

Merged
binaryfire merged 11 commits into0.4from
feat/auth-cache
Apr 19, 2026
Merged

feat(auth): optional auth user cache for EloquentUserProvider#371
binaryfire merged 11 commits into0.4from
feat/auth-cache

Conversation

@binaryfire
Copy link
Copy Markdown
Collaborator

Normally every authenticated request runs a SELECT to hydrate Auth::user() via EloquentUserProvider::retrieveById(). That becomes a major bottleneck in a high concurrency framework like Hypervel, and is the single biggest source of avoidable DB load on authenticated endpoints.

This PR adds opt-in auth user caching. Default behaviour is unchanged (disabled), and one config change turns it on per provider. Everything else on the provider (retrieveByCredentials, retrieveByToken, validateCredentials) stays uncached - credential and token lookups should always see fresh data.

Enabling it

Minimum setup for a single Redis node:

AUTH_USERS_CACHE_ENABLED=true
AUTH_USERS_CACHE_STORE=redis

High-scale recommended setup (swoole L1 plus redis L2):

AUTH_USERS_CACHE_ENABLED=true
AUTH_USERS_CACHE_STORE=stack

The cache block sits inside each auth provider in config/auth.php, so different providers can have different cache settings:

'providers' => [
    'users' => [
        'driver' => 'eloquent',
        'model' => App\Models\User::class,
        'cache' => [
            'enabled' => env('AUTH_USERS_CACHE_ENABLED', false),
            'store'   => env('AUTH_USERS_CACHE_STORE'),
            'ttl'     => env('AUTH_USERS_CACHE_TTL', 300),
            'prefix'  => env('AUTH_USERS_CACHE_PREFIX', 'auth_users'),
        ],
    ],
],

New public surface

On EloquentUserProvider:

  • enableCache(?string $storeName, int $ttl = 300, ?string $prefix = 'auth_users'): static
  • isCacheEnabled(): bool
  • clearUserCache(mixed $identifier): void
  • resolveUserCacheKeyUsing(Closure $callback): void (static, global)
  • flushState(): void (static, test isolation)

On AuthManager and the Auth facade:

  • Auth::clearUserCache(mixed $identifier, ?string $guard = null): void

No new classes or driver types. CreatesUserProviders::createEloquentProvider() calls enableCache() when the config says enabled => true, otherwise the provider behaves exactly as before. The EloquentUserProvider constructor signature is unchanged, so existing code that instantiates it directly (including tests) keeps working.

Supported cache stores

enableCache() validates the resolved Store against a whitelist before any instance state is mutated. A rejected store throws InvalidArgumentException on first guard resolution, leaving the provider in its uncached state.

Store Accepted Notes
redis yes Shared across workers and nodes.
database yes Shared. Slower than Redis but still offers a good performance increase when combined with in-memory tables.
file yes Node-local. Single-instance deployments only.
swoole yes Node-local, shared memory. Ideal L1 tier inside a stack.
stack yes Multi-tier. Recommended high-scale topology.
session no User-scoped; would cache data inside one user's session, invisible to every other request.
array no Coroutine-local after the upcoming rewrite; nothing persists across requests.
null no Discards every write.
failover no Ambiguous fallback semantics; silently degrades onto a potentially unsafe tier.

The check uses instanceof, so subclasses of supported stores are accepted too.

Microcaching at scale

At the kind of RPS where this feature actually matters, a stack with Swoole as L1 and Redis as L2 is the recommended shape. Hot reads stay in the node-local Swoole Table for a few seconds; cold reads fall through to the shared Redis tier and auto-repopulate the L1 for the next hit. The effect eliminates most Redis round-trips for authenticated requests at high concurrency.

The tradeoff is cross-node staleness. StackStore::forget() only clears the L1 cache on the local node; it doesn't propagate to other nodes. With a node-local L1, other nodes keep serving their stale L1 entries until their own L1 TTL expires. That's usually not an issue since the window is usally small (eg. 3-5s).

For apps that need strict global consistency, using plain redis is still a major performance win.

Cache key format

Default key: {prefix}:{model-FQCN}:{identifier}, e.g. auth_users:App\Models\User:42. The fully qualified class name is always included so providers using different user models never collide, even when two models share a basename across namespaces (App\Models\User vs Admin\Models\User). This matches the convention PermissionManager already uses for similar keys.

For situations where the cache key needs to be set dynamically (eg. multi-tenant apps where the same user id resolves to different rows per tenant), register a global resolver in a service provider's boot():

EloquentUserProvider::resolveUserCacheKeyUsing(
    fn (mixed $identifier) => tenantId() . ':' . $identifier,
);

This produces keys like auth_users:App\Models\User:5:42 (FQCN, tenant 5, user 42). The resolver is a static callback rather than a config so it's re-evaluated on every request.

Invalidation model

Four layers:

  1. Provider writes via save(). updateRememberToken() and rehashPasswordIfRequired() both call $user->save(), which fires the saved event. Layer 2 handles the actual forget; neither method contains an explicit cache clear.
  2. Model events. When caching is enabled, the provider registers saved and deleted listeners on the user model class. Any Eloquent write ($user->save(), $user->update(), $user->delete()) triggers invalidation.
  3. Manual: Auth::clearUserCache($id[, $guard]). For writes that bypass Eloquent events - pivot writes for roles and permissions, raw DB queries, mass update(), queue jobs and external processes.
  4. TTL expiry. 300s default. Catches anything the above three miss.

Under the hood, the listener doesn't hold provider references. Each provider registers a plain-data descriptor (storeName, prefix, modelSegment) under a deterministic hash; duplicate configs get collapsed to a single descriptor on insert. The listener iterates descriptors for the model class, re-resolves each store by name via the cache manager, then calls forget(). This keeps the feature safe against AuthManager::forgetGuards() plus re-resolve cycles in long-running workers. Nothing gets leaked.

Multi-guard / multi-model behaviour

Matters when you have a setup like web => User, admin => Admin:

  • One provider shared by multiple guards (e.g. web / api / sanctum all pointing at users): one Auth::clearUserCache($id) call clears the single shared keyspace. Calling for each guard is redundant.
  • Different guards with different models: Auth::clearUserCache(42) with no guard argument only clears the default guard's model. Clearing a Admin needs Auth::clearUserCache(42, 'admin').

Swoole safety

  • Provider is a worker-lifetime singleton via AuthManager::$guards. The caching-related instance properties ($cache, $cacheStoreName, $cacheTtl, $cachePrefix, $modelSegment) are set once in enableCache() and treated as read-only after, so there's no per-request mutable instance state.
  • The global static key resolver runs in the current coroutine context on every call, so per-request callables read the correct value.
  • The descriptor registry is plain data, no provider or store refs, forgetGuards() plus re-resolve can't retain dead objects.
  • Store resolution on invalidation goes through Container::getInstance()->make('cache')->store($name), which is cached inside CacheManager after the first call. So subsequent invalidations are all hashmap lookups.
  • Listener registration is guarded by Model::getEventDispatcher() being non-null, and the registered flag is only set after a successful attach. If the dispatcher isn't set at first enableCache() time (unusual, but might happen in some edge cases), the next call retries.
  • The supported store whitelist is enforced at enableCache() time, before any state is mutated, so the feature can't be wired up against a user-scoped store (SessionStore), a useless store (Array / Null), or an ambiguously composite one (like Failover).

Gotchas worth knowing

  • withQuery() callbacks run on the uncached fetch. Whatever relations get eager-loaded on that first call are the relations cached for everyone. This is usually what you want for auth. A good use of this is caching user permissions as well.
  • Mass writes that bypass Eloquent events don't fire the listener: User::query()->update([...]), raw DB::update(), pivot attach / detach without touching the User model. Developers will have to use Auth::clearUserCache() after those.
  • The store whitelist only checks the outer class for stack. A stack built with an unsupported inner tier (e.g. [array, redis]) won't be caught by the check. That has been documented.

Test coverage

  • tests/Auth/AuthEloquentUserProviderCacheTest.php - new unit suite, covering cache disabled path, basic operation (miss, hit, sentinel), key format (FQCN, prefix normalization, custom resolver), the whitelist (accept, reject, fail-without-mutate), manual invalidation, and flushState.
  • tests/Integration/Auth/EloquentUserProviderCacheTest.php - new integration suite, real event dispatcher and DB. Tests covering model events firing real invalidation, descriptor dedup, multi-descriptor fan-out, listener registered only once, updateRememberToken and rehashPasswordIfRequired paths, the dispatcher-missing case, and withQuery compatibility.
  • tests/Auth/AuthManagerTest.php - 4 new tests covering Auth::clearUserCache: custom guards without getProvider, specified guard routes to the right provider and model, default guard uses the active key resolver, forgetGuards() plus re-resolve doesn't accumulate descriptors.

Not included

  • No clearAllUserCache(). Dynamic key resolvers (eg. tenant segments) can't be enumerated without cache tags, and not every supported store has tags. Per-user clearing is fine for almost every scenario. If a developer wants to do a bulk clear, they can clear the cache. Support for tags when using the Redis store could be a nice addition though.

Adds an opt-in, per-provider cache for retrieveById() lookups — the hot
path on every authenticated request in a Swoole worker, where one DB
query per request per worker adds up fast at scale.

Public surface:
 - enableCache(?string $storeName, int $ttl, ?string $prefix): static
 - isCacheEnabled(): bool
 - clearUserCache(mixed $identifier): void
 - resolveUserCacheKeyUsing(Closure): void (static, global)
 - flushState(): void (static, test isolation)

Key design points:
 - Accepts a store NAME (nullable = default store), not a pre-resolved
   repository, so the invalidation registry can re-resolve by name via
   Container::getInstance()->make('cache') without holding provider refs.
 - Validates the resolved Store against a whitelist (RedisStore,
   DatabaseStore, FileStore, SwooleStore, StackStore) via instanceof
   BEFORE any instance state is mutated. Rejected stores leave the
   provider uncached and don't register descriptors or listeners.
 - Cache keys are {prefix}:{model-FQCN}:{identifier}, with the FQCN
   memoized once in enableCache() so the hot path doesn't recompute it.
   The FQCN segment is always present so providers using different
   models never collide — even when two models share a basename across
   namespaces.
 - Global static resolveUserCacheKeyUsing() callback shapes the
   identifier segment, evaluated at call time so tenant-like per-request
   context is current (a config-file closure would capture boot-time).
 - Model saved/deleted listeners invalidate via a descriptor registry of
   plain-data arrays keyed by deterministic hash — no provider refs, so
   AuthManager::forgetGuards() / re-resolve cycles don't leak providers,
   and duplicate configs collapse on insert.
 - Listener registration is guarded by Model::getEventDispatcher() being
   non-null, and the cacheEventsRegistered flag is only set after a
   successful attach, so if the dispatcher isn't set yet at first
   enableCache() time the next call retries.
 - Null-sentinel caching of missing users prevents repeated DB queries
   for nonexistent ids.
 - updateRememberToken/rehashPasswordIfRequired rely on the listener
   firing on the save they already do — no explicit clear inside them.
…he()

createEloquentProvider() now calls $provider->enableCache() when the
cache config block has enabled=true. Passes the store NAME (nullable;
null = default store), TTL, and prefix straight through — the provider
re-resolves the store by name on invalidation, so no pre-resolved
repository is held at this layer.

No change to the database provider path.
Exposes a guard-scoped wrapper around EloquentUserProvider::clearUserCache()
for apps that need to invalidate cached user entries from write paths
that bypass Eloquent model events — pivot-table writes for roles and
permissions, mass update() / raw DB queries, queue jobs, external
processes, etc.

Signature: clearUserCache(mixed $identifier, ?string $guard = null): void

Behaviour:
 - Omitted $guard uses the default guard.
 - Resolves the guard's existing provider via getProvider() instead of
   constructing a throwaway one.
 - No-op when the guard doesn't expose getProvider() (custom guards that
   don't use GuardHelpers — checked via method_exists to avoid hitting
   AuthManager::__call and triggering BadMethodCallException at runtime).
 - No-op when the provider isn't an EloquentUserProvider or caching is
   disabled.

The provider's key resolver (if set) runs, so in a tenant-aware setup
this clears the entry for the current tenant context.
Adds the @method static docblock entry for Auth::clearUserCache() next
to resolveUsersUsing so IDEs autocomplete and phpstan understands the
call through the facade.
Adds the per-provider 'cache' block to the default 'users' provider:

    'cache' => [
        'enabled' => env('AUTH_USERS_CACHE_ENABLED', false),
        'store'   => env('AUTH_USERS_CACHE_STORE'),
        'ttl'     => env('AUTH_USERS_CACHE_TTL', 300),
        'prefix'  => env('AUTH_USERS_CACHE_PREFIX', 'auth_users'),
    ],

Disabled by default; a single env flip turns it on. The block is
preceded by a prescriptive docblock listing supported stores (redis,
database, file, swoole, stack), rejected stores (array, null, session,
failover), cross-node behaviour (fully-shared vs node-local vs
stack microcaching), and the recommended high-scale topology
(stack = [swoole L1 → redis L2]). Full rationale lives in the auth
caching documentation; the config comment stays short and prescriptive.
Testbench's auth.providers.users wholly replaces the framework's users
entry during config merge (the merge is one level deep on 'providers',
not recursive), so any key we want present at runtime needs to be
repeated here — including the new 'cache' block.

Same shape and same docblock as the framework config. Disabled by
default, so this change is purely discoverability/consistency for
testbench-bootstrapped test suites and skeletons cloned from testbench.
Adds a new 'User Lookup Cache' section to the auth package README
covering:

 - Purpose: opt-in cross-request cache for retrieveById() to eliminate
   one DB query per authenticated request under Swoole.
 - Config examples: minimum Redis setup and the high-scale stack
   (swoole L1 + redis L2) setup.
 - Microcaching rationale: what it is, why it matters at scale, and the
   concrete wins (p99 latency, Redis tier sizing, bandwidth, resilience
   during brief Redis outages).
 - Invalidation model: four layers (provider writes via save(), model
   events, manual Auth::clearUserCache, TTL), with explicit notes on
   same-node propagation via Swoole Table and bounded cross-node
   staleness when using a stack with a node-local L1.
 - Manual invalidation API: full parameter semantics, how the model
   is chosen from the guard, multi-guard / multi-model behaviour
   (one-provider-shared-by-many-guards vs different-model-per-guard),
   tenant-resolver interaction, and no-op conditions.
 - TTL guidance per scenario.
 - Store selection guide mirroring the config docblock.
 - Tenant-aware cache keys with the resolveUserCacheKeyUsing() pattern
   and why it has to be a static callback, not a config closure.
 - Gotchas: withQuery caching effect, mass-update event bypass, outer-
   only stack validation.
 - Threat-model notes for high-security providers.

Marked with a @todo so the whole section can be lifted into the 0.4
documentation site once it lands — the README is just the interim home.
…scriber

Resets the provider's static state (the cache key resolver, descriptor
registry, and listener-registered flag) between tests so cache-related
tests are isolated from each other.

Placed alphabetically between AuthenticationException::flushState() and
Middleware\Authenticate::flushState().
New dedicated test class kept separate from AuthEloquentUserProviderTest
so the base-provider coverage stays focused and the caching feature
gets its own home.

Covers, with Mockery doubles only (no container, no DB, no dispatcher):

 - Cache disabled: retrieveById falls through to the DB path with no
   cache interaction.
 - Basic operation: miss → DB → put, hit → no DB, missing-user null
   sentinel stored on miss, null returned on sentinel hit.
 - retrieveByCredentials and retrieveByToken never touch the cache.
 - Key format: default {prefix}:{FQCN}:{id}, null/'' prefix normalizes
   to the feature default, custom resolver used for the identifier
   segment, custom resolver receives the raw identifier, FQCN always
   present in the key even with a custom resolver.
 - Supported-store whitelist (data provider over Redis/Database/File/
   Swoole/Stack): enableCache accepts each.
 - Unsupported-store rejection (data provider over Array/Null/Session/
   Failover): enableCache throws InvalidArgumentException.
 - Validation-failure ordering: after a rejected store, the provider
   is still disabled, the descriptor registry is empty, the
   events-registered flag is untouched, and retrieveById falls through
   to the DB path — confirms 'validate before mutate'.
 - Manual clearUserCache: forgets the right key, respects the custom
   resolver, no-op when caching is disabled.
 - flushState resets the resolver, descriptor registry, and
   events-registered flag.

Uses a stub Model subclass (EloquentCacheProviderUserStub) because the
registerCacheInvalidationEvents() path touches ::getEventDispatcher()
on the model class, which needs a real class to resolve.
New Testbench-based integration suite covering the paths that need a
real event dispatcher and a real Model class (things the unit suite
can't exercise). The cache repository itself is still a Mockery double
so forget() calls are verifiable — what matters here is the real
saved/deleted event firing, not the cache backend.

Covers:

 - Cache is cleared on user save.
 - Cache is cleared on user delete.
 - Identical (store, prefix, modelSegment) configs dedupe in the
   descriptor registry.
 - Two distinct (store, prefix) configs for the same model each get
   invalidated on a single save — the listener attaches once, iterates
   both descriptors, fires exactly one forget() per descriptor (guards
   against accidental double-attach).
 - updateRememberToken and rehashPasswordIfRequired clear the cache
   via the saved event — no explicit clear inside the methods.
 - enableCache skips listener registration when the model has no
   dispatcher yet, leaves the events-registered flag unset so a later
   enableCache() call retries, but still registers the descriptor.
 - withQuery compatibility: the withQuery callback runs only on the
   cache-miss fetch; subsequent calls hit the cache.

Uses Hypervel\Foundation\Auth\User (via #[WithMigration] + RefreshDatabase)
so the model is a real Eloquent User with dispatch set up by the
framework boot.
Adds four tests exercising the clearUserCache() convenience method end
to end through the manager + provider + cache stack (cache manager
stubbed via Container::instance, repository + store as Mockery doubles):

 - Custom guard without getProvider() — method_exists guard kicks in,
   call is a no-op, no BadMethodCallException from __call forwarding.
 - Specified guard uses that guard's provider/model — clearing user 42
   on the 'admin' guard forgets AuthManagerCacheAdminStub:42 (not
   AuthManagerCacheUserStub:42), proving the guard determines the
   provider which determines the model.
 - Default guard + custom resolver — clearUserCache with no guard name
   uses the default guard's provider, and the key resolver runs so the
   forget key is the tenant-scoped one, not the raw id.
 - forgetGuards() + re-resolve does not accumulate provider descriptors:
   after forgetting the guard cache and re-resolving, the descriptor
   registry still has exactly one entry for the model. Guards against a
   potential leak where repeated resolve/forget cycles would grow the
   registry.

Stub classes (AuthManagerCacheUserStub / AuthManagerCacheAdminStub)
extend Foundation\Auth\User so getAuthIdentifier() and the model
dispatcher behaviour are real.
@binaryfire binaryfire merged commit a66571b into 0.4 Apr 19, 2026
32 checks passed
return new EloquentUserProvider($this->app['hash'], $config['model']);
$provider = new EloquentUserProvider($this->app['hash'], $config['model']);

if (! empty($config['cache']['enabled'])) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@binaryfire it will be better to add some defense here?

if ($config['cache']['enabled'] ?? false)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @albertcht. I think ! empty() and $config['cache']['enabled'] ?? false are functionally identical here? empty() handles missing keys without warnings the same way ?? does, and both produce identical if outcomes for every value enabled could be (bool, int, string, null, unset, etc.).

But I'm happy to change it to ?? false if you prefer. Let me know.

$modelClass = $this->model;

// Insert or replace the descriptor — duplicate configs collapse.
$descriptorKey = md5(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@binaryfire Just to provide an idea: Could we optimize Hypervel by replacing md5 with xxh3 or xxh128, where we don't need to maintain compatibility with Laravel's hashing results?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@albertcht Yeah that's a great idea. md5 is only used in a handful of places but it's definitely worth switching. I've added that to my list of changes to make for 0.4. Could we mark this as resolved? I'll make that change across the whole codebase separately.

@binaryfire binaryfire deleted the feat/auth-cache branch April 22, 2026 12:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants